Expand description
Axum error handling inspired by anyhow
§Comparison to anyhow
Assume a function can_fail
that returns Result<T, E>
or Option<T>
.
With anyhow
, you can do the following:
use anyhow::{Context, Result};
let value = can_fail().context("Error message")?;
For many types of programs, this is more than enough.
But for web backends, you don’t only want to report an error.
You want to return a response with a proper HTTP status code.
Then you want to log the error (using tracing
).
This is what axum-ctx
does:
// Use a wildcard for the best user experience
use axum_ctx::*;
let value = can_fail().ctx(StatusCode::BAD_REQUEST).log_msg("Error message")?;
If an error occurs, the user gets the error message “400 Bad Request” corresponding to the status code that you specified. But you can replace this default message with a custom error message to be shown to the user:
let value = can_fail()
.ctx(StatusCode::UNAUTHORIZED)
// Shown to the user
.user_msg("You are not allowed to access this resource!")
// NOT shown to the user, only for the log
.log_msg("Someone tries to pentest you")?;
A second call of user_msg
replaces the the user error message.
But calling log_msg
multiple times creates a backtrace:
fn returns_resp_result() -> RespResult<()> {
can_fail().ctx(StatusCode::NOT_FOUND).log_msg("Inner error message")
}
let value = returns_resp_result()
.log_msg("Outer error message")?;
The code above leads to the following log message:
2024-05-08T22:17:53.769240Z INFO axum_ctx: 404 Not Found
0: Outer error message
1: Inner error message
§Lazy evaluation
Similar to with_context
provided by anyhow
, axum-ctx
also supports lazy evaluation of messages.
You just provide a closure to user_msg
or log_msg
:
let resource_name = "foo";
let value = can_fail()
.ctx(StatusCode::UNAUTHORIZED)
.user_msg(|| format!("You are not allowed to access the resource {resource_name}!"))
.log_msg(|| format!("Someone tries to access {resource_name}"))?;
.user_msg(format!("…"))
creates the string on the heap even if can_fail
didn’t return Err
(or None
for options).
.user_msg(|| format!("…"))
(a closure with two pipes ||
) only creates the string if Err
/None
actually occurred.
§Logging
axum-ctx
uses tracing
for logging.
This means that you need to initialize a tracing subscriber in your program first before being able to see the log messages of axum-ctx
.
axum-ctx
automatically chooses a tracing level depending on the chosen status code.
Here is the default range mapping (status codes less than 100 or bigger than 999 are not allowed):
Status Code | Level |
---|---|
100..400 | Debug |
400..500 | Info |
500..600 | Error |
600..1000 | Trace |
You can change the default level for one or more status codes using change_tracing_level
on program initialization
§Example
Assume that you want to get all salaries from a database and then return their maximum from an Axum API.
The steps required:
1. Get all salaries from the database. This might fail for example if the database isn’t reachable
➡️ You need to handle a
Result
2. Determine the maximum salary. But if there were no salaries in the database, there is no maximum
➡️ You need to handle an
Option
3. Return the maximum salary as JSON.
First, let’s define a function to get all salaries:
async fn salaries_from_db() -> Result<Vec<f64>, String> {
// Imagine getting this error while trying to connect to the database.
Err(String::from("Database unreachable"))
}
Now, let’s see how to do proper handling of Result
and Option
in an Axum handler:
use axum::Json;
use http::StatusCode;
use tracing::{error, info};
async fn max_salary() -> Result<Json<f64>, (StatusCode, &'static str)> {
let salaries = match salaries_from_db().await {
Ok(salaries) => salaries,
Err(error) => {
error!("Failed to get all salaries from the DB\n{error}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong. Please try again later",
));
}
};
match salaries.iter().copied().reduce(f64::max) {
Some(max_salary) => Ok(Json(max_salary)),
None => {
info!("The maximum salary was requested although there are no salaries");
Err((StatusCode::NOT_FOUND, "There are no salaries yet!"))
}
}
}
Now, compare the code above with the one below that uses axum-ctx
:
use axum_ctx::*;
async fn max_salary() -> RespResult<Json<f64>> {
salaries_from_db()
.await
.ctx(StatusCode::INTERNAL_SERVER_ERROR)
.user_msg("Something went wrong. Please try again later")
.log_msg("Failed to get all salaries from the DB")?
.iter()
.copied()
.reduce(f64::max)
.ctx(StatusCode::NOT_FOUND)
.user_msg("There are no salaries yet!")
.log_msg("The maximum salary was requested although there are no salaries")
.map(Json)
}
Isn’t that a wonderful chain? ⛓️ It is basically a “one-liner” if you ignore the pretty formatting.
The user gets the message “Something went wrong. Please try again later”. In your terminal, you get the following log message:
2024-05-08T22:17:53.769240Z ERROR axum_ctx: Something went wrong. Please try again later
0: Failed to get all salaries from the DB
1: Database unreachable
“What about map_or_else
and ok_or_else
?”, you might ask.
You can use them if you prefer chaining like me, but the code will not be as concise as the one above with axum_ctx
.
You can compare:
async fn max_salary() -> Result<Json<f64>, (StatusCode, &'static str)> {
salaries_from_db()
.await
.map_err(|error| {
error!("Failed to get all salaries from the DB\n{error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong. Please try again later",
)
})?
.iter()
.copied()
.reduce(f64::max)
.ok_or_else(|| {
info!("The maximum salary was requested although there are no salaries");
(StatusCode::NOT_FOUND, "There are no salaries yet!")
})
.map(Json)
}
Structs§
- An error message.
- An error to be used as the error variant of a request handler.
- An HTTP status code (
status-code
in RFC 7230 et al.).
Enums§
- The tracing level that maps to
tracing::Level
.
Traits§
- Conversion to a
Result
withRespErr
as the error. - Addition of custom user and log error messages to a
Result<T, RespErr>
.
Functions§
- Change the default tracing level for a status code.